// globalConfig.js
// ============================================================================
// ============================================================================

// Provides global variables used by the entire program.
// Most of this should be configuration.

// Timing multiplier for entire game engine.
let gameSpeed = 1;

// Colors
const BLUE = { r: 0x67, g: 0xd7, b: 0xf0 };
const GREEN = { r: 0xa6, g: 0xe0, b: 0x2c };
const PINK = { r: 0xfa, g: 0x24, b: 0x73 };
const ORANGE = { r: 0xfe, g: 0x95, b: 0x22 };
const allColors = [BLUE, GREEN, PINK, ORANGE];

// Gameplay
const getSpawnDelay = () => {
    const spawnDelayMax = 1400;
    const spawnDelayMin = 550;
    const spawnDelay = spawnDelayMax - state.game.cubeCount * 3.1;
    return Math.max(spawnDelay, spawnDelayMin);
}
const doubleStrongEnableScore = 2000;
// Number of cubes that must be smashed before activating a feature.
const slowmoThreshold = 10;
const strongThreshold = 25;
const spinnerThreshold = 25;

// Interaction state
let pointerIsDown = false;
// The last known position of the primary pointer in screen coordinates.`
let pointerScreen = { x: 0, y: 0 };
// Same as `pointerScreen`, but converted to scene coordinates in rAF.
let pointerScene = { x: 0, y: 0 };
// Minimum speed of pointer before "hits" are counted.
const minPointerSpeed = 60;
// The hit speed affects the direction the target post-hit. This number dampens that force.
const hitDampening = 0.1;
// Backboard receives shadows and is the farthest negative Z position of entities.
const backboardZ = -400;
const shadowColor = '#262e36';
// How much air drag is applied to standard objects
const airDrag = 0.022;
const gravity = 0.3;
// Spark config
const sparkColor = 'rgba(170,221,255,.9)';
const sparkThickness = 2.2;
const airDragSpark = 0.1;
// Track pointer positions to show trail
const touchTrailColor = 'rgba(170,221,255,.62)';
const touchTrailThickness = 7;
const touchPointLife = 120;
const touchPoints = [];
// Size of in-game targets. This affects rendered size and hit area.
const targetRadius = 40;
const targetHitRadius = 50;
const makeTargetGlueColor = target => {
    // const alpha = (target.health - 1) / (target.maxHealth - 1);
    // return `rgba(170,221,255,${alpha.toFixed(3)})`;
    return 'rgb(170,221,255)';
};
// Size of target fragments
const fragRadius = targetRadius / 3;



// Game canvas element needed in setup.js and interaction.js
const canvas = document.querySelector('#c');

// 3D camera config
// Affects perspective
const cameraDistance = 900;
// Does not affect perspective
const sceneScale = 1;
// Objects that get too close to the camera will be faded out to transparent over this range.
// const cameraFadeStartZ = 0.8*cameraDistance - 6*targetRadius;
const cameraFadeStartZ = 0.45 * cameraDistance;
const cameraFadeEndZ = 0.65 * cameraDistance;
const cameraFadeRange = cameraFadeEndZ - cameraFadeStartZ;

// Globals used to accumlate all vertices/polygons in each frame
const allVertices = [];
const allPolys = [];
const allShadowVertices = [];
const allShadowPolys = [];




// state.js
// ============================================================================
// ============================================================================

///////////
// Enums //
///////////

// Game Modes
const GAME_MODE_RANKED = Symbol('GAME_MODE_RANKED');
const GAME_MODE_CASUAL = Symbol('GAME_MODE_CASUAL');

// Available Menus
const MENU_MAIN = Symbol('MENU_MAIN');
const MENU_PAUSE = Symbol('MENU_PAUSE');
const MENU_SCORE = Symbol('MENU_SCORE');



//////////////////
// Global State //
//////////////////

const state = {
    game: {
        mode: GAME_MODE_RANKED,
        // Run time of current game.
        time: 0,
        // Player score.
        score: 0,
        // Total number of cubes smashed in game.
        cubeCount: 0
    },
    menus: {
        // Set to `null` to hide all menus
        active: MENU_MAIN
    }
};


////////////////////////////
// Global State Selectors //
////////////////////////////

const isInGame = () => !state.menus.active;
const isMenuVisible = () => !!state.menus.active;
const isCasualGame = () => state.game.mode === GAME_MODE_CASUAL;
const isPaused = () => state.menus.active === MENU_PAUSE;


///////////////////
// Local Storage //
///////////////////

const highScoreKey = '__menja__highScore';
const getHighScore = () => {
    const raw = localStorage.getItem(highScoreKey);
    return raw ? parseInt(raw, 10) : 0;
};
const setHighScore = score => localStorage.setItem(highScoreKey, String(score))




// utils.js
// ============================================================================
// ============================================================================


const invariant = (condition, message) => {
    if (!condition) throw new Error(message);
};


/////////
// DOM //
/////////

const $ = selector => document.querySelector(selector);
const handleClick = (element, handler) => element.addEventListener('click', handler);
const handlePointerDown = (element, handler) => {
    element.addEventListener('touchstart', handler);
    element.addEventListener('mousedown', handler);
};



////////////////////////
// Formatting Helpers //
////////////////////////

// Converts a number into a formatted string with thousand separators.
const formatNumber = num => num.toLocaleString();



////////////////////
// Math Constants //
////////////////////

const PI = Math.PI;
const TAU = Math.PI * 2;
const ETA = Math.PI * 0.5;



//////////////////
// Math Helpers //
//////////////////

// Clamps a number between min and max values (inclusive)
const clamp = (num, min, max) => Math.min(Math.max(num, min), max);

// Linearly interpolate between numbers a and b by a specific amount.
// mix >= 0 && mix <= 1
const lerp = (a, b, mix) => (b - a) * mix + a;




////////////////////
// Random Helpers //
////////////////////

// Generates a random number between min (inclusive) and max (exclusive)
const random = (min, max) => Math.random() * (max - min) + min;

// Generates a random integer between and possibly including min and max values
const randomInt = (min, max) => ((Math.random() * (max - min + 1)) | 0) + min;

// Returns a random element from an array
const pickOne = arr => arr[Math.random() * arr.length | 0];




///////////////////
// Color Helpers //
///////////////////

// Converts an { r, g, b } color object to a 6-digit hex code.
const colorToHex = color => {
    return '#' +
        (color.r | 0).toString(16).padStart(2, '0') +
        (color.g | 0).toString(16).padStart(2, '0') +
        (color.b | 0).toString(16).padStart(2, '0');
};

// Operates on an { r, g, b } color object.
// Returns string hex code.
// `lightness` must range from 0 to 1. 0 is pure black, 1 is pure white.
const shadeColor = (color, lightness) => {
    let other, mix;
    if (lightness < 0.5) {
        other = 0;
        mix = 1 - (lightness * 2);
    } else {
        other = 255;
        mix = lightness * 2 - 1;
    }
    return '#' +
        (lerp(color.r, other, mix) | 0).toString(16).padStart(2, '0') +
        (lerp(color.g, other, mix) | 0).toString(16).padStart(2, '0') +
        (lerp(color.b, other, mix) | 0).toString(16).padStart(2, '0');
};





////////////////////
// Timing Helpers //
////////////////////

const _allCooldowns = [];

const makeCooldown = (rechargeTime, units = 1) => {
    let timeRemaining = 0;
    let lastTime = 0;

    const initialOptions = { rechargeTime, units };

    const updateTime = () => {
        const now = state.game.time;
        // Reset time remaining if time goes backwards.
        if (now < lastTime) {
            timeRemaining = 0;
        } else {
            // update...
            timeRemaining -= now - lastTime;
            if (timeRemaining < 0) timeRemaining = 0;
        }
        lastTime = now;
    };

    const canUse = () => {
        updateTime();
        return timeRemaining <= (rechargeTime * (units - 1));
    };

    const cooldown = {
        canUse,
        useIfAble() {
            const usable = canUse();
            if (usable) timeRemaining += rechargeTime;
            return usable;
        },
        mutate(options) {
            if (options.rechargeTime) {
                // Apply recharge time delta so change takes effect immediately.
                timeRemaining -= rechargeTime - options.rechargeTime;
                if (timeRemaining < 0) timeRemaining = 0;
                rechargeTime = options.rechargeTime;
            }
            if (options.units) units = options.units;
        },
        reset() {
            timeRemaining = 0;
            lastTime = 0;
            this.mutate(initialOptions);
        }
    };

    _allCooldowns.push(cooldown);

    return cooldown;
};

const resetAllCooldowns = () => _allCooldowns.forEach(cooldown => cooldown.reset());

const makeSpawner = ({ chance, cooldownPerSpawn, maxSpawns }) => {
    const cooldown = makeCooldown(cooldownPerSpawn, maxSpawns);
    return {
        shouldSpawn() {
            return Math.random() <= chance && cooldown.useIfAble();
        },
        mutate(options) {
            if (options.chance) chance = options.chance;
            cooldown.mutate({
                rechargeTime: options.cooldownPerSpawn,
                units: options.maxSpawns
            });
        }
    };
};




////////////////////
// Vector Helpers //
////////////////////

const normalize = v => {
    const mag = Math.hypot(v.x, v.y, v.z);
    return {
        x: v.x / mag,
        y: v.y / mag,
        z: v.z / mag
    };
}

// Curried math helpers
const add = a => b => a + b;
// Curried vector helpers
const scaleVector = scale => vector => {
    vector.x *= scale;
    vector.y *= scale;
    vector.z *= scale;
};








////////////////
// 3D Helpers //
////////////////

// Clone array and all vertices.
function cloneVertices(vertices) {
    return vertices.map(v => ({ x: v.x, y: v.y, z: v.z }));
}

// Copy vertex data from one array into another.
// Arrays must be the same length.
function copyVerticesTo(arr1, arr2) {
    const len = arr1.length;
    for (let i = 0; i < len; i++) {
        const v1 = arr1[i];
        const v2 = arr2[i];
        v2.x = v1.x;
        v2.y = v1.y;
        v2.z = v1.z;
    }
}

// Compute triangle midpoint.
// Mutates `middle` property of given `poly`.
function computeTriMiddle(poly) {
    const v = poly.vertices;
    poly.middle.x = (v[0].x + v[1].x + v[2].x) / 3;
    poly.middle.y = (v[0].y + v[1].y + v[2].y) / 3;
    poly.middle.z = (v[0].z + v[1].z + v[2].z) / 3;
}

// Compute quad midpoint.
// Mutates `middle` property of given `poly`.
function computeQuadMiddle(poly) {
    const v = poly.vertices;
    poly.middle.x = (v[0].x + v[1].x + v[2].x + v[3].x) / 4;
    poly.middle.y = (v[0].y + v[1].y + v[2].y + v[3].y) / 4;
    poly.middle.z = (v[0].z + v[1].z + v[2].z + v[3].z) / 4;
}

function computePolyMiddle(poly) {
    if (poly.vertices.length === 3) {
        computeTriMiddle(poly);
    } else {
        computeQuadMiddle(poly);
    }
}

// Compute distance from any polygon (tri or quad) midpoint to camera.
// Sets `depth` property of given `poly`.
// Also triggers midpoint calculation, which mutates `middle` property of `poly`.
function computePolyDepth(poly) {
    computePolyMiddle(poly);
    const dX = poly.middle.x;
    const dY = poly.middle.y;
    const dZ = poly.middle.z - cameraDistance;
    poly.depth = Math.hypot(dX, dY, dZ);
}

// Compute normal of any polygon. Uses normalized vector cross product.
// Mutates `normalName` property of given `poly`.
function computePolyNormal(poly, normalName) {
    // Store quick refs to vertices
    const v1 = poly.vertices[0];
    const v2 = poly.vertices[1];
    const v3 = poly.vertices[2];
    // Calculate difference of vertices, following winding order.
    const ax = v1.x - v2.x;
    const ay = v1.y - v2.y;
    const az = v1.z - v2.z;
    const bx = v1.x - v3.x;
    const by = v1.y - v3.y;
    const bz = v1.z - v3.z;
    // Cross product
    const nx = ay * bz - az * by;
    const ny = az * bx - ax * bz;
    const nz = ax * by - ay * bx;
    // Compute magnitude of normal and normalize
    const mag = Math.hypot(nx, ny, nz);
    const polyNormal = poly[normalName];
    polyNormal.x = nx / mag;
    polyNormal.y = ny / mag;
    polyNormal.z = nz / mag;
}

// Apply translation/rotation/scale to all given vertices.
// If `vertices` and `target` are the same array, the vertices will be mutated in place.
// If `vertices` and `target` are different arrays, `vertices` will not be touched, instead the
// transformed values from `vertices` will be written to `target` array.
function transformVertices(vertices, target, tX, tY, tZ, rX, rY, rZ, sX, sY, sZ) {
    // Matrix multiplcation constants only need calculated once for all vertices.
    const sinX = Math.sin(rX);
    const cosX = Math.cos(rX);
    const sinY = Math.sin(rY);
    const cosY = Math.cos(rY);
    const sinZ = Math.sin(rZ);
    const cosZ = Math.cos(rZ);

    // Using forEach() like map(), but with a (recycled) target array.
    vertices.forEach((v, i) => {
        const targetVertex = target[i];
        // X axis rotation
        const x1 = v.x;
        const y1 = v.z * sinX + v.y * cosX;
        const z1 = v.z * cosX - v.y * sinX;
        // Y axis rotation
        const x2 = x1 * cosY - z1 * sinY;
        const y2 = y1;
        const z2 = x1 * sinY + z1 * cosY;
        // Z axis rotation
        const x3 = x2 * cosZ - y2 * sinZ;
        const y3 = x2 * sinZ + y2 * cosZ;
        const z3 = z2;

        // Scale, Translate, and set the transform.
        targetVertex.x = x3 * sX + tX;
        targetVertex.y = y3 * sY + tY;
        targetVertex.z = z3 * sZ + tZ;
    });
}

// 3D projection on a single vertex.
// Directly mutates the vertex.
const projectVertex = v => {
    const focalLength = cameraDistance * sceneScale;
    const depth = focalLength / (cameraDistance - v.z);
    v.x = v.x * depth;
    v.y = v.y * depth;
};

// 3D projection on a single vertex.
// Mutates a secondary target vertex.
const projectVertexTo = (v, target) => {
    const focalLength = cameraDistance * sceneScale;
    const depth = focalLength / (cameraDistance - v.z);
    target.x = v.x * depth;
    target.y = v.y * depth;
};





// PERF.js
// ============================================================================
// ============================================================================

// Dummy no-op functions.
// I use these in a special build for custom performance profiling.
const PERF_START = () => { };
const PERF_END = () => { };
const PERF_UPDATE = () => { };




// 3dModels.js
// ============================================================================
// ============================================================================

// Define models once. The origin is the center of the model.

// A simple cube, 8 vertices, 6 quads.
// Defaults to an edge length of 2 units, can be influenced with `scale`.
function makeCubeModel({ scale = 1 }) {
    return {
        vertices: [
            // top
            { x: -scale, y: -scale, z: scale },
            { x: scale, y: -scale, z: scale },
            { x: scale, y: scale, z: scale },
            { x: -scale, y: scale, z: scale },
            // bottom
            { x: -scale, y: -scale, z: -scale },
            { x: scale, y: -scale, z: -scale },
            { x: scale, y: scale, z: -scale },
            { x: -scale, y: scale, z: -scale }
        ],
        polys: [
            // z = 1
            { vIndexes: [0, 1, 2, 3] },
            // z = -1
            { vIndexes: [7, 6, 5, 4] },
            // y = 1
            { vIndexes: [3, 2, 6, 7] },
            // y = -1
            { vIndexes: [4, 5, 1, 0] },
            // x = 1
            { vIndexes: [5, 6, 2, 1] },
            // x = -1
            { vIndexes: [0, 3, 7, 4] }
        ]
    };
}

// Not very optimized - lots of duplicate vertices are generated.
function makeRecursiveCubeModel({ recursionLevel, splitFn, color, scale = 1 }) {
    const getScaleAtLevel = level => 1 / (3 ** level);

    // We can model level 0 manually. It's just a single, centered, cube.
    let cubeOrigins = [{ x: 0, y: 0, z: 0 }];

    // Recursively replace cubes with smaller cubes.
    for (let i = 1; i <= recursionLevel; i++) {
        const scale = getScaleAtLevel(i) * 2;
        const cubeOrigins2 = [];
        cubeOrigins.forEach(origin => {
            cubeOrigins2.push(...splitFn(origin, scale));
        });
        cubeOrigins = cubeOrigins2;
    }

    const finalModel = { vertices: [], polys: [] };

    // Generate single cube model and scale it.
    const cubeModel = makeCubeModel({ scale: 1 });
    cubeModel.vertices.forEach(scaleVector(getScaleAtLevel(recursionLevel)));

    // Compute the max distance x, y, or z origin values will be.
    // Same result as `Math.max(...cubeOrigins.map(o => o.x))`, but much faster.
    const maxComponent = getScaleAtLevel(recursionLevel) * (3 ** recursionLevel - 1);

    // Place cube geometry at each origin.
    cubeOrigins.forEach((origin, cubeIndex) => {
        // To compute occlusion (shading), find origin component with greatest
        // magnitude and normalize it relative to `maxComponent`.
        const occlusion = Math.max(
            Math.abs(origin.x),
            Math.abs(origin.y),
            Math.abs(origin.z)
        ) / maxComponent;
        // At lower iterations, occlusion looks better lightened up a bit.
        const occlusionLighter = recursionLevel > 2
            ? occlusion
            : (occlusion + 0.8) / 1.8;
        // Clone, translate vertices to origin, and apply scale
        finalModel.vertices.push(
            ...cubeModel.vertices.map(v => ({
                x: (v.x + origin.x) * scale,
                y: (v.y + origin.y) * scale,
                z: (v.z + origin.z) * scale
            }))
        );
        // Clone polys, shift referenced vertex indexes, and compute color.
        finalModel.polys.push(
            ...cubeModel.polys.map(poly => ({
                vIndexes: poly.vIndexes.map(add(cubeIndex * 8))
            }))
        );
    });

    return finalModel;
}


// o: Vector3D - Position of cube's origin (center).
// s: Vector3D - Determines size of menger sponge.
function mengerSpongeSplit(o, s) {
    return [
        // Top
        { x: o.x + s, y: o.y - s, z: o.z + s },
        { x: o.x + s, y: o.y - s, z: o.z + 0 },
        { x: o.x + s, y: o.y - s, z: o.z - s },
        { x: o.x + 0, y: o.y - s, z: o.z + s },
        { x: o.x + 0, y: o.y - s, z: o.z - s },
        { x: o.x - s, y: o.y - s, z: o.z + s },
        { x: o.x - s, y: o.y - s, z: o.z + 0 },
        { x: o.x - s, y: o.y - s, z: o.z - s },
        // Bottom
        { x: o.x + s, y: o.y + s, z: o.z + s },
        { x: o.x + s, y: o.y + s, z: o.z + 0 },
        { x: o.x + s, y: o.y + s, z: o.z - s },
        { x: o.x + 0, y: o.y + s, z: o.z + s },
        { x: o.x + 0, y: o.y + s, z: o.z - s },
        { x: o.x - s, y: o.y + s, z: o.z + s },
        { x: o.x - s, y: o.y + s, z: o.z + 0 },
        { x: o.x - s, y: o.y + s, z: o.z - s },
        // Middle
        { x: o.x + s, y: o.y + 0, z: o.z + s },
        { x: o.x + s, y: o.y + 0, z: o.z - s },
        { x: o.x - s, y: o.y + 0, z: o.z + s },
        { x: o.x - s, y: o.y + 0, z: o.z - s }
    ];
}



// Helper to optimize models by merging duplicate vertices within a threshold,
// and removing all polys that share the same vertices.
// Directly mutates the model.
function optimizeModel(model, threshold = 0.0001) {
    const { vertices, polys } = model;

    const compareVertices = (v1, v2) => (
        Math.abs(v1.x - v2.x) < threshold &&
        Math.abs(v1.y - v2.y) < threshold &&
        Math.abs(v1.z - v2.z) < threshold
    );

    const comparePolys = (p1, p2) => {
        const v1 = p1.vIndexes;
        const v2 = p2.vIndexes;
        return (
            (
                v1[0] === v2[0] ||
                v1[0] === v2[1] ||
                v1[0] === v2[2] ||
                v1[0] === v2[3]
            ) && (
                v1[1] === v2[0] ||
                v1[1] === v2[1] ||
                v1[1] === v2[2] ||
                v1[1] === v2[3]
            ) && (
                v1[2] === v2[0] ||
                v1[2] === v2[1] ||
                v1[2] === v2[2] ||
                v1[2] === v2[3]
            ) && (
                v1[3] === v2[0] ||
                v1[3] === v2[1] ||
                v1[3] === v2[2] ||
                v1[3] === v2[3]
            )
        );
    };


    vertices.forEach((v, i) => {
        v.originalIndexes = [i];
    });

    for (let i = vertices.length - 1; i >= 0; i--) {
        for (let ii = i - 1; ii >= 0; ii--) {
            const v1 = vertices[i];
            const v2 = vertices[ii];
            if (compareVertices(v1, v2)) {
                vertices.splice(i, 1);
                v2.originalIndexes.push(...v1.originalIndexes);
                break;
            }
        }
    }

    vertices.forEach((v, i) => {
        polys.forEach(p => {
            p.vIndexes.forEach((vi, ii, arr) => {
                const vo = v.originalIndexes;
                if (vo.includes(vi)) {
                    arr[ii] = i;
                }
            });
        });
    });

    polys.forEach(p => {
        const vi = p.vIndexes;
        p.sum = vi[0] + vi[1] + vi[2] + vi[3];
    });
    polys.sort((a, b) => b.sum - a.sum);

    // Assumptions:
    // 1. Each poly will either have no duplicates or 1 duplicate.
    // 2. If two polys are equal, they are both hidden (two cubes touching),
    //    therefore both can be removed.
    for (let i = polys.length - 1; i >= 0; i--) {
        for (let ii = i - 1; ii >= 0; ii--) {
            const p1 = polys[i];
            const p2 = polys[ii];
            if (p1.sum !== p2.sum) break;
            if (comparePolys(p1, p2)) {
                polys.splice(i, 1);
                polys.splice(ii, 1);
                i--;
                break;
            }
        }
    }

    return model;
}





// Entity.js
// ============================================================================
// ============================================================================

class Entity {
    constructor({ model, color, wireframe = false }) {
        const vertices = cloneVertices(model.vertices);
        const shadowVertices = cloneVertices(model.vertices);
        const colorHex = colorToHex(color);
        const darkColorHex = shadeColor(color, 0.4);

        const polys = model.polys.map(p => ({
            vertices: p.vIndexes.map(vIndex => vertices[vIndex]),
            color: color, // custom rgb color object
            wireframe: wireframe,
            strokeWidth: wireframe ? 2 : 0, // Set to non-zero value to draw stroke
            strokeColor: colorHex, // must be a CSS color string
            strokeColorDark: darkColorHex, // must be a CSS color string
            depth: 0,
            middle: { x: 0, y: 0, z: 0 },
            normalWorld: { x: 0, y: 0, z: 0 },
            normalCamera: { x: 0, y: 0, z: 0 }
        }));

        const shadowPolys = model.polys.map(p => ({
            vertices: p.vIndexes.map(vIndex => shadowVertices[vIndex]),
            wireframe: wireframe,
            normalWorld: { x: 0, y: 0, z: 0 }
        }));

        this.projected = {}; // Will store 2D projected data
        this.model = model;
        this.vertices = vertices;
        this.polys = polys;
        this.shadowVertices = shadowVertices;
        this.shadowPolys = shadowPolys;
        this.reset();
    }

    // Better names: resetEntity, resetTransform, resetEntityTransform
    reset() {
        this.x = 0;
        this.y = 0;
        this.z = 0;
        this.xD = 0;
        this.yD = 0;
        this.zD = 0;

        this.rotateX = 0;
        this.rotateY = 0;
        this.rotateZ = 0;
        this.rotateXD = 0;
        this.rotateYD = 0;
        this.rotateZD = 0;

        this.scaleX = 1;
        this.scaleY = 1;
        this.scaleZ = 1;

        this.projected.x = 0;
        this.projected.y = 0;
    }

    transform() {
        transformVertices(
            this.model.vertices,
            this.vertices,
            this.x,
            this.y,
            this.z,
            this.rotateX,
            this.rotateY,
            this.rotateZ,
            this.scaleX,
            this.scaleY,
            this.scaleZ
        );

        copyVerticesTo(this.vertices, this.shadowVertices);
    }

    // Projects origin point, stored as `projected` property.
    project() {
        projectVertexTo(this, this.projected);
    }
}





// getTarget.js
// ============================================================================
// ============================================================================

// All active targets
const targets = [];

// Pool target instances by color, using a Map.
// keys are color objects, and values are arrays of targets.
// Also pool wireframe instances separately.
const targetPool = new Map(allColors.map(c => ([c, []])));
const targetWireframePool = new Map(allColors.map(c => ([c, []])));



const getTarget = (() => {

    const slowmoSpawner = makeSpawner({
        chance: 0.5,
        cooldownPerSpawn: 10000,
        maxSpawns: 1
    });

    let doubleStrong = false;
    const strongSpawner = makeSpawner({
        chance: 0.3,
        cooldownPerSpawn: 12000,
        maxSpawns: 1
    });

    const spinnerSpawner = makeSpawner({
        chance: 0.1,
        cooldownPerSpawn: 10000,
        maxSpawns: 1
    });

    // Cached array instances, no need to allocate every time.
    const axisOptions = [
        ['x', 'y'],
        ['y', 'z'],
        ['z', 'x']
    ];

    function getTargetOfStyle(color, wireframe) {
        const pool = wireframe ? targetWireframePool : targetPool;
        let target = pool.get(color).pop();
        if (!target) {
            target = new Entity({
                model: optimizeModel(makeRecursiveCubeModel({
                    recursionLevel: 1,
                    splitFn: mengerSpongeSplit,
                    scale: targetRadius
                })),
                color: color,
                wireframe: wireframe
            });

            // Init any properties that will be used.
            // These will not be automatically reset when recycled.
            target.color = color;
            target.wireframe = wireframe;
            // Some properties don't have their final value yet.
            // Initialize with any value of the right type.
            target.hit = false;
            target.maxHealth = 0;
            target.health = 0;
        }
        return target;
    }

    return function getTarget() {
        if (doubleStrong && state.game.score <= doubleStrongEnableScore) {
            doubleStrong = false;
            // Spawner is reset automatically when game resets.
        } else if (!doubleStrong && state.game.score > doubleStrongEnableScore) {
            doubleStrong = true;
            strongSpawner.mutate({ maxSpawns: 2 });
        }

        // Target Parameters
        // --------------------------------
        let color = pickOne([BLUE, GREEN, ORANGE]);
        let wireframe = false;
        let health = 1;
        let maxHealth = 3;
        const spinner = state.game.cubeCount >= spinnerThreshold && isInGame() && spinnerSpawner.shouldSpawn();

        // Target Parameter Overrides
        // --------------------------------
        if (state.game.cubeCount >= slowmoThreshold && slowmoSpawner.shouldSpawn()) {
            color = BLUE;
            wireframe = true;
        }
        else if (state.game.cubeCount >= strongThreshold && strongSpawner.shouldSpawn()) {
            color = PINK;
            health = 3;
        }

        // Target Creation
        // --------------------------------
        const target = getTargetOfStyle(color, wireframe);
        target.hit = false;
        target.maxHealth = maxHealth;
        target.health = health;
        updateTargetHealth(target, 0);

        const spinSpeeds = [
            Math.random() * 0.1 - 0.05,
            Math.random() * 0.1 - 0.05
        ];

        if (spinner) {
            // Ends up spinning a random axis
            spinSpeeds[0] = -0.25;
            spinSpeeds[1] = 0;
            target.rotateZ = random(0, TAU);
        }

        const axes = pickOne(axisOptions);

        spinSpeeds.forEach((spinSpeed, i) => {
            switch (axes[i]) {
                case 'x':
                    target.rotateXD = spinSpeed;
                    break;
                case 'y':
                    target.rotateYD = spinSpeed;
                    break;
                case 'z':
                    target.rotateZD = spinSpeed;
                    break;
            }
        });

        return target;
    }
})();


const updateTargetHealth = (target, healthDelta) => {
    target.health += healthDelta;
    // Only update stroke on non-wireframe targets.
    // Showing "glue" is a temporary attempt to display health. For now, there's
    // no reason to have wireframe targets with high health, so we're fine.
    if (!target.wireframe) {
        const strokeWidth = target.health - 1;
        const strokeColor = makeTargetGlueColor(target);
        for (let p of target.polys) {
            p.strokeWidth = strokeWidth;
            p.strokeColor = strokeColor;
        }
    }
};


const returnTarget = target => {
    target.reset();
    const pool = target.wireframe ? targetWireframePool : targetPool;
    pool.get(target.color).push(target);
};


function resetAllTargets() {
    while (targets.length) {
        returnTarget(targets.pop());
    }
}





// createBurst.js
// ============================================================================
// ============================================================================

// Track all active fragments
const frags = [];
// Pool inactive fragments by color, using a Map.
// keys are color objects, and values are arrays of fragments.
// // Also pool wireframe instances separately.
const fragPool = new Map(allColors.map(c => ([c, []])));
const fragWireframePool = new Map(allColors.map(c => ([c, []])));


const createBurst = (() => {
    // Precompute some private data to be reused for all bursts.
    const basePositions = mengerSpongeSplit({ x: 0, y: 0, z: 0 }, fragRadius * 2);
    const positions = cloneVertices(basePositions);
    const prevPositions = cloneVertices(basePositions);
    const velocities = cloneVertices(basePositions);

    const basePositionNormals = basePositions.map(normalize);
    const positionNormals = cloneVertices(basePositionNormals);


    const fragCount = basePositions.length;

    function getFragForTarget(target) {
        const pool = target.wireframe ? fragWireframePool : fragPool;
        let frag = pool.get(target.color).pop();
        if (!frag) {
            frag = new Entity({
                model: makeCubeModel({ scale: fragRadius }),
                color: target.color,
                wireframe: target.wireframe
            });
            frag.color = target.color;
            frag.wireframe = target.wireframe;
        }
        return frag;
    }

    return (target, force = 1) => {
        // Calculate fragment positions, and what would have been the previous positions
        // when still a part of the larger target.
        transformVertices(
            basePositions, positions,
            target.x, target.y, target.z,
            target.rotateX, target.rotateY, target.rotateZ,
            1, 1, 1
        );
        transformVertices(
            basePositions, prevPositions,
            target.x - target.xD, target.y - target.yD, target.z - target.zD,
            target.rotateX - target.rotateXD, target.rotateY - target.rotateYD, target.rotateZ - target.rotateZD,
            1, 1, 1
        );

        // Compute velocity of each fragment, based on previous positions.
        // Will write to `velocities` array.
        for (let i = 0; i < fragCount; i++) {
            const position = positions[i];
            const prevPosition = prevPositions[i];
            const velocity = velocities[i];

            velocity.x = position.x - prevPosition.x;
            velocity.y = position.y - prevPosition.y;
            velocity.z = position.z - prevPosition.z;
        }



        // Apply target rotation to normals
        transformVertices(
            basePositionNormals, positionNormals,
            0, 0, 0,
            target.rotateX, target.rotateY, target.rotateZ,
            1, 1, 1
        );


        for (let i = 0; i < fragCount; i++) {
            const position = positions[i];
            const velocity = velocities[i];
            const normal = positionNormals[i];

            const frag = getFragForTarget(target);

            frag.x = position.x;
            frag.y = position.y;
            frag.z = position.z;
            frag.rotateX = target.rotateX;
            frag.rotateY = target.rotateY;
            frag.rotateZ = target.rotateZ;


            const burstSpeed = 2 * force;
            const randSpeed = 2 * force;
            const rotateScale = 0.015;
            frag.xD = velocity.x + (normal.x * burstSpeed) + (Math.random() * randSpeed);
            frag.yD = velocity.y + (normal.y * burstSpeed) + (Math.random() * randSpeed);
            frag.zD = velocity.z + (normal.z * burstSpeed) + (Math.random() * randSpeed);
            frag.rotateXD = frag.xD * rotateScale;
            frag.rotateYD = frag.yD * rotateScale;
            frag.rotateZD = frag.zD * rotateScale;

            frags.push(frag);
        };
    }
})();


const returnFrag = frag => {
    frag.reset();
    const pool = frag.wireframe ? fragWireframePool : fragPool;
    pool.get(frag.color).push(frag);
};





// sparks.js
// ============================================================================
// ============================================================================

const sparks = [];
const sparkPool = [];


function addSpark(x, y, xD, yD) {
    const spark = sparkPool.pop() || {};

    spark.x = x + xD * 0.5;
    spark.y = y + yD * 0.5;
    spark.xD = xD;
    spark.yD = yD;
    spark.life = random(200, 300);
    spark.maxLife = spark.life;

    sparks.push(spark);

    return spark;
}


// Spherical spark burst
function sparkBurst(x, y, count, maxSpeed) {
    const angleInc = TAU / count;
    for (let i = 0; i < count; i++) {
        const angle = i * angleInc + angleInc * Math.random();
        const speed = (1 - Math.random() ** 3) * maxSpeed;
        addSpark(
            x,
            y,
            Math.sin(angle) * speed,
            Math.cos(angle) * speed
        );
    }
}


// Make a target "leak" sparks from all vertices.
// This is used to create the effect of target glue "shedding".
let glueShedVertices;
function glueShedSparks(target) {
    if (!glueShedVertices) {
        glueShedVertices = cloneVertices(target.vertices);
    } else {
        copyVerticesTo(target.vertices, glueShedVertices);
    }

    glueShedVertices.forEach(v => {
        if (Math.random() < 0.4) {
            projectVertex(v);
            addSpark(
                v.x,
                v.y,
                random(-12, 12),
                random(-12, 12)
            );
        }
    });
}

function returnSpark(spark) {
    sparkPool.push(spark);
}





// hud.js
// ============================================================================
// ============================================================================

const hudContainerNode = $('.hud');

function setHudVisibility(visible) {
    if (visible) {
        hudContainerNode.style.display = 'block';
    } else {
        hudContainerNode.style.display = 'none';
    }
}


///////////
// Score //
///////////
const scoreNode = $('.score-lbl');
const cubeCountNode = $('.cube-count-lbl');

function renderScoreHud() {
    if (isCasualGame()) {
        scoreNode.style.display = 'none';
        cubeCountNode.style.opacity = 1;
    } else {
        scoreNode.innerText = `SCORE: ${state.game.score}`;
        scoreNode.style.display = 'block';
        cubeCountNode.style.opacity = 0.65;
    }
    cubeCountNode.innerText = `CUBES SMASHED: ${state.game.cubeCount}`;
}

renderScoreHud();


//////////////////
// Pause Button //
//////////////////

handlePointerDown($('.pause-btn'), () => pauseGame());


////////////////////
// Slow-Mo Status //
////////////////////

const slowmoNode = $('.slowmo');
const slowmoBarNode = $('.slowmo__bar');

function renderSlowmoStatus(percentRemaining) {
    slowmoNode.style.opacity = percentRemaining === 0 ? 0 : 1;
    slowmoBarNode.style.transform = `scaleX(${percentRemaining.toFixed(3)})`;
}





// menus.js
// ============================================================================
// ============================================================================

// Top-level menu containers
const menuContainerNode = $('.menus');
const menuMainNode = $('.menu--main');
const menuPauseNode = $('.menu--pause');
const menuScoreNode = $('.menu--score');

const finalScoreLblNode = $('.final-score-lbl');
const highScoreLblNode = $('.high-score-lbl');



function showMenu(node) {
    node.classList.add('active');
}

function hideMenu(node) {
    node.classList.remove('active');
}

function renderMenus() {
    hideMenu(menuMainNode);
    hideMenu(menuPauseNode);
    hideMenu(menuScoreNode);

    switch (state.menus.active) {
        case MENU_MAIN:
            showMenu(menuMainNode);
            break;
        case MENU_PAUSE:
            showMenu(menuPauseNode);
            break;
        case MENU_SCORE:
            finalScoreLblNode.textContent = formatNumber(state.game.score);
            if (state.game.score > getHighScore()) {
                highScoreLblNode.textContent = 'New High Score!';
            } else {
                highScoreLblNode.textContent = `High Score: ${formatNumber(getHighScore())}`;
            }
            showMenu(menuScoreNode);
            break;
    }

    setHudVisibility(!isMenuVisible());
    menuContainerNode.classList.toggle('has-active', isMenuVisible());
    menuContainerNode.classList.toggle('interactive-mode', isMenuVisible() && pointerIsDown);
}

renderMenus();



////////////////////
// Button Actions //
////////////////////

// Main Menu
handleClick($('.play-normal-btn'), () => {
    setGameMode(GAME_MODE_RANKED);
    setActiveMenu(null);
    resetGame();
});

handleClick($('.play-casual-btn'), () => {
    setGameMode(GAME_MODE_CASUAL);
    setActiveMenu(null);
    resetGame();
});

// Pause Menu
handleClick($('.resume-btn'), () => resumeGame());
handleClick($('.menu-btn--pause'), () => setActiveMenu(MENU_MAIN));

// Score Menu
handleClick($('.play-again-btn'), () => {
    setActiveMenu(null);
    resetGame();
});

handleClick($('.menu-btn--score'), () => setActiveMenu(MENU_MAIN));





// actions.js
// ============================================================================
// ============================================================================

//////////////////
// MENU ACTIONS //
//////////////////

function setActiveMenu(menu) {
    state.menus.active = menu;
    renderMenus();
}


/////////////////
// HUD ACTIONS //
/////////////////

function setScore(score) {
    state.game.score = score;
    renderScoreHud();
}

function incrementScore(inc) {
    if (isInGame()) {
        state.game.score += inc;
        if (state.game.score < 0) {
            state.game.score = 0;
        }
        renderScoreHud();
    }
}

function setCubeCount(count) {
    state.game.cubeCount = count;
    renderScoreHud();
}

function incrementCubeCount(inc) {
    if (isInGame()) {
        state.game.cubeCount += inc;
        renderScoreHud();
    }
}


//////////////////
// GAME ACTIONS //
//////////////////

function setGameMode(mode) {
    state.game.mode = mode;
}

function resetGame() {
    resetAllTargets();
    state.game.time = 0;
    resetAllCooldowns();
    setScore(0);
    setCubeCount(0);
    spawnTime = getSpawnDelay();
}

function pauseGame() {
    isInGame() && setActiveMenu(MENU_PAUSE);
}

function resumeGame() {
    isPaused() && setActiveMenu(null);
}

function endGame() {
    setActiveMenu(MENU_SCORE);
    handleCanvasPointerUp();
    // Update high score if needed, AFTER rendering the score menu.
    if (state.game.score > getHighScore()) {
        setHighScore(state.game.score);
    }
}




////////////////////////
// KEYBOARD SHORTCUTS //
////////////////////////

window.addEventListener('keydown', event => {
    if (event.key === 'p') {
        isPaused() ? resumeGame() : pauseGame();
    }
});





// tick.js
// ============================================================================
// ============================================================================


let spawnTime = 0;
const maxSpawnX = 450;
const pointerDelta = { x: 0, y: 0 };
const pointerDeltaScaled = { x: 0, y: 0 };

// Temp slowmo state. Should be relocated once this stabilizes.
const slowmoDuration = 1500;
let slowmoRemaining = 0;
let spawnExtra = 0;
const spawnExtraDelay = 300;
let targetSpeed = 1;


function tick(width, height, simTime, simSpeed, lag) {
    PERF_START('frame');
    PERF_START('tick');

    state.game.time += simTime;

    if (slowmoRemaining > 0) {
        slowmoRemaining -= simTime;
        if (slowmoRemaining < 0) {
            slowmoRemaining = 0;
        }
        targetSpeed = pointerIsDown ? 0.075 : 0.3;
    } else {
        const menuPointerDown = isMenuVisible() && pointerIsDown;
        targetSpeed = menuPointerDown ? 0.025 : 1;
    }

    renderSlowmoStatus(slowmoRemaining / slowmoDuration);

    gameSpeed += (targetSpeed - gameSpeed) / 22 * lag;
    gameSpeed = clamp(gameSpeed, 0, 1);

    const centerX = width / 2;
    const centerY = height / 2;

    const simAirDrag = 1 - (airDrag * simSpeed);
    const simAirDragSpark = 1 - (airDragSpark * simSpeed);

    // Pointer Tracking
    // -------------------

    // Compute speed and x/y deltas.
    // There is also a "scaled" variant taking game speed into account. This serves two purposes:
    //  - Lag won't create large spikes in speed/deltas
    //  - In slow mo, speed is increased proportionately to match "reality". Without this boost,
    //    it feels like your actions are dampened in slow mo.
    const forceMultiplier = 1 / (simSpeed * 0.75 + 0.25);
    pointerDelta.x = 0;
    pointerDelta.y = 0;
    pointerDeltaScaled.x = 0;
    pointerDeltaScaled.y = 0;
    const lastPointer = touchPoints[touchPoints.length - 1];

    if (pointerIsDown && lastPointer && !lastPointer.touchBreak) {
        pointerDelta.x = (pointerScene.x - lastPointer.x);
        pointerDelta.y = (pointerScene.y - lastPointer.y);
        pointerDeltaScaled.x = pointerDelta.x * forceMultiplier;
        pointerDeltaScaled.y = pointerDelta.y * forceMultiplier;
    }
    const pointerSpeed = Math.hypot(pointerDelta.x, pointerDelta.y);
    const pointerSpeedScaled = pointerSpeed * forceMultiplier;

    // Track points for later calculations, including drawing trail.
    touchPoints.forEach(p => p.life -= simTime);

    if (pointerIsDown) {
        touchPoints.push({
            x: pointerScene.x,
            y: pointerScene.y,
            life: touchPointLife
        });
    }

    while (touchPoints[0] && touchPoints[0].life <= 0) {
        touchPoints.shift();
    }


    // Entity Manipulation
    // --------------------
    PERF_START('entities');

    // Spawn targets
    spawnTime -= simTime;
    if (spawnTime <= 0) {
        if (spawnExtra > 0) {
            spawnExtra--;
            spawnTime = spawnExtraDelay;
        } else {
            spawnTime = getSpawnDelay();
        }
        const target = getTarget();
        const spawnRadius = Math.min(centerX * 0.8, maxSpawnX);
        target.x = (Math.random() * spawnRadius * 2 - spawnRadius);
        target.y = centerY + targetHitRadius;
        target.z = (Math.random() * targetRadius * 2 - targetRadius);
        target.xD = Math.random() * (target.x * -2 / 120);
        target.yD = -20;
        targets.push(target);
    }

    // Animate targets and remove when offscreen
    const leftBound = -centerX + targetRadius;
    const rightBound = centerX - targetRadius;
    const ceiling = -centerY - 120;
    const boundDamping = 0.4;

    targetLoop:
    for (let i = targets.length - 1; i >= 0; i--) {
        const target = targets[i];
        target.x += target.xD * simSpeed;
        target.y += target.yD * simSpeed;

        if (target.y < ceiling) {
            target.y = ceiling;
            target.yD = 0;
        }

        if (target.x < leftBound) {
            target.x = leftBound;
            target.xD *= -boundDamping;
        } else if (target.x > rightBound) {
            target.x = rightBound;
            target.xD *= -boundDamping;
        }

        if (target.z < backboardZ) {
            target.z = backboardZ;
            target.zD *= -boundDamping;
        }

        target.yD += gravity * simSpeed;
        target.rotateX += target.rotateXD * simSpeed;
        target.rotateY += target.rotateYD * simSpeed;
        target.rotateZ += target.rotateZD * simSpeed;
        target.transform();
        target.project();

        // Remove if offscreen
        if (target.projected.y > centerY + targetHitRadius * 2) {
            targets.splice(i, 1);
            returnTarget(target);
            if (isInGame()) {
                if (isCasualGame()) {
                    incrementScore(-25);
                } else {
                    endGame();
                }
            }
            continue;
        }


        // If pointer is moving really fast, we want to hittest multiple points along the path.
        // We can't use scaled pointer speed to determine this, since we care about actual screen
        // distance covered.
        const hitTestCount = Math.ceil(pointerSpeed / targetRadius * 2);
        // Start loop at `1` and use `<=` check, so we skip 0% and end up at 100%.
        // This omits the previous point position, and includes the most recent.
        for (let ii = 1; ii <= hitTestCount; ii++) {
            const percent = 1 - (ii / hitTestCount);
            const hitX = pointerScene.x - pointerDelta.x * percent;
            const hitY = pointerScene.y - pointerDelta.y * percent;
            const distance = Math.hypot(
                hitX - target.projected.x,
                hitY - target.projected.y
            );

            if (distance <= targetHitRadius) {
                // Hit! (though we don't want to allow hits on multiple sequential frames)
                if (!target.hit) {
                    target.hit = true;

                    target.xD += pointerDeltaScaled.x * hitDampening;
                    target.yD += pointerDeltaScaled.y * hitDampening;
                    target.rotateXD += pointerDeltaScaled.y * 0.001;
                    target.rotateYD += pointerDeltaScaled.x * 0.001;

                    const sparkSpeed = 7 + pointerSpeedScaled * 0.125;

                    if (pointerSpeedScaled > minPointerSpeed) {
                        target.health--;
                        incrementScore(10);

                        if (target.health <= 0) {
                            incrementCubeCount(1);
                            createBurst(target, forceMultiplier);
                            sparkBurst(hitX, hitY, 8, sparkSpeed);
                            if (target.wireframe) {
                                slowmoRemaining = slowmoDuration;
                                spawnTime = 0;
                                spawnExtra = 2;
                            }
                            targets.splice(i, 1);
                            returnTarget(target);
                        } else {
                            sparkBurst(hitX, hitY, 8, sparkSpeed);
                            glueShedSparks(target);
                            updateTargetHealth(target, 0);
                        }
                    } else {
                        incrementScore(5);
                        sparkBurst(hitX, hitY, 3, sparkSpeed);
                    }
                }
                // Break the current loop and continue the outer loop.
                // This skips to processing the next target.
                continue targetLoop;
            }
        }

        // This code will only run if target hasn't been "hit".
        target.hit = false;
    }

    // Animate fragments and remove when offscreen.
    const fragBackboardZ = backboardZ + fragRadius;
    // Allow fragments to move off-screen to sides for a while, since shadows are still visible.
    const fragLeftBound = -width;
    const fragRightBound = width;

    for (let i = frags.length - 1; i >= 0; i--) {
        const frag = frags[i];
        frag.x += frag.xD * simSpeed;
        frag.y += frag.yD * simSpeed;
        frag.z += frag.zD * simSpeed;

        frag.xD *= simAirDrag;
        frag.yD *= simAirDrag;
        frag.zD *= simAirDrag;

        if (frag.y < ceiling) {
            frag.y = ceiling;
            frag.yD = 0;
        }

        if (frag.z < fragBackboardZ) {
            frag.z = fragBackboardZ;
            frag.zD *= -boundDamping;
        }

        frag.yD += gravity * simSpeed;
        frag.rotateX += frag.rotateXD * simSpeed;
        frag.rotateY += frag.rotateYD * simSpeed;
        frag.rotateZ += frag.rotateZD * simSpeed;
        frag.transform();
        frag.project();

        // Removal conditions
        if (
            // Bottom of screen
            frag.projected.y > centerY + targetHitRadius ||
            // Sides of screen
            frag.projected.x < fragLeftBound ||
            frag.projected.x > fragRightBound ||
            // Too close to camera
            frag.z > cameraFadeEndZ
        ) {
            frags.splice(i, 1);
            returnFrag(frag);
            continue;
        }
    }

    // 2D sparks
    for (let i = sparks.length - 1; i >= 0; i--) {
        const spark = sparks[i];
        spark.life -= simTime;
        if (spark.life <= 0) {
            sparks.splice(i, 1);
            returnSpark(spark);
            continue;
        }
        spark.x += spark.xD * simSpeed;
        spark.y += spark.yD * simSpeed;
        spark.xD *= simAirDragSpark;
        spark.yD *= simAirDragSpark;
        spark.yD += gravity * simSpeed;
    }

    PERF_END('entities');

    // 3D transforms
    // -------------------

    PERF_START('3D');

    // Aggregate all scene vertices/polys
    allVertices.length = 0;
    allPolys.length = 0;
    allShadowVertices.length = 0;
    allShadowPolys.length = 0;
    targets.forEach(entity => {
        allVertices.push(...entity.vertices);
        allPolys.push(...entity.polys);
        allShadowVertices.push(...entity.shadowVertices);
        allShadowPolys.push(...entity.shadowPolys);
    });

    frags.forEach(entity => {
        allVertices.push(...entity.vertices);
        allPolys.push(...entity.polys);
        allShadowVertices.push(...entity.shadowVertices);
        allShadowPolys.push(...entity.shadowPolys);
    });

    // Scene calculations/transformations
    allPolys.forEach(p => computePolyNormal(p, 'normalWorld'));
    allPolys.forEach(computePolyDepth);
    allPolys.sort((a, b) => b.depth - a.depth);

    // Perspective projection
    allVertices.forEach(projectVertex);

    allPolys.forEach(p => computePolyNormal(p, 'normalCamera'));

    PERF_END('3D');

    PERF_START('shadows');

    // Rotate shadow vertices to light source perspective
    transformVertices(
        allShadowVertices,
        allShadowVertices,
        0, 0, 0,
        TAU / 8, 0, 0,
        1, 1, 1
    );

    allShadowPolys.forEach(p => computePolyNormal(p, 'normalWorld'));

    const shadowDistanceMult = Math.hypot(1, 1);
    const shadowVerticesLength = allShadowVertices.length;
    for (let i = 0; i < shadowVerticesLength; i++) {
        const distance = allVertices[i].z - backboardZ;
        allShadowVertices[i].z -= shadowDistanceMult * distance;
    }
    transformVertices(
        allShadowVertices,
        allShadowVertices,
        0, 0, 0,
        -TAU / 8, 0, 0,
        1, 1, 1
    );
    allShadowVertices.forEach(projectVertex);

    PERF_END('shadows');

    PERF_END('tick');
}





// draw.js
// ============================================================================
// ============================================================================

function draw(ctx, width, height, viewScale) {
    PERF_START('draw');

    const halfW = width / 2;
    const halfH = height / 2;


    // 3D Polys
    // ---------------
    ctx.lineJoin = 'bevel';

    PERF_START('drawShadows');
    ctx.fillStyle = shadowColor;
    ctx.strokeStyle = shadowColor;
    allShadowPolys.forEach(p => {
        if (p.wireframe) {
            ctx.lineWidth = 2;
            ctx.beginPath();
            const { vertices } = p;
            const vCount = vertices.length;
            const firstV = vertices[0];
            ctx.moveTo(firstV.x, firstV.y);
            for (let i = 1; i < vCount; i++) {
                const v = vertices[i];
                ctx.lineTo(v.x, v.y);
            }
            ctx.closePath();
            ctx.stroke();
        } else {
            ctx.beginPath();
            const { vertices } = p;
            const vCount = vertices.length;
            const firstV = vertices[0];
            ctx.moveTo(firstV.x, firstV.y);
            for (let i = 1; i < vCount; i++) {
                const v = vertices[i];
                ctx.lineTo(v.x, v.y);
            }
            ctx.closePath();
            ctx.fill();
        }
    });
    PERF_END('drawShadows');

    PERF_START('drawPolys');

    allPolys.forEach(p => {
        if (!p.wireframe && p.normalCamera.z < 0) return;

        if (p.strokeWidth !== 0) {
            ctx.lineWidth = p.normalCamera.z < 0 ? p.strokeWidth * 0.5 : p.strokeWidth;
            ctx.strokeStyle = p.normalCamera.z < 0 ? p.strokeColorDark : p.strokeColor;
        }

        const { vertices } = p;
        const lastV = vertices[vertices.length - 1];
        const fadeOut = p.middle.z > cameraFadeStartZ;

        if (!p.wireframe) {
            const normalLight = p.normalWorld.y * 0.5 + p.normalWorld.z * -0.5;
            const lightness = normalLight > 0
                ? 0.1
                : ((normalLight ** 32 - normalLight) / 2) * 0.9 + 0.1;
            ctx.fillStyle = shadeColor(p.color, lightness);
        }

        // Fade out polys close to camera. `globalAlpha` must be reset later.
        if (fadeOut) {
            // If polygon gets really close to camera (outside `cameraFadeRange`) the alpha
            // can go negative, which has the appearance of alpha = 1. So, we'll clamp it at 0.
            ctx.globalAlpha = Math.max(0, 1 - (p.middle.z - cameraFadeStartZ) / cameraFadeRange);
        }

        ctx.beginPath();
        ctx.moveTo(lastV.x, lastV.y);
        for (let v of vertices) {
            ctx.lineTo(v.x, v.y);
        }

        if (!p.wireframe) {
            ctx.fill();
        }
        if (p.strokeWidth !== 0) {
            ctx.stroke();
        }

        if (fadeOut) {
            ctx.globalAlpha = 1;
        }
    });
    PERF_END('drawPolys');


    PERF_START('draw2D');

    // 2D Sparks
    // ---------------
    ctx.strokeStyle = sparkColor;
    ctx.lineWidth = sparkThickness;
    ctx.beginPath();
    sparks.forEach(spark => {
        ctx.moveTo(spark.x, spark.y);
        // Shrink sparks to zero length as they die.
        // Speed up shrinking as life approaches 0 (root curve).
        // Note that sparks already get smaller over time as their speed slows
        // down from damping. So this is like a double scale down. To counter this
        // a bit and keep the sparks larger for longer, we'll also increase the scale
        // a bit after applying the root curve.
        const scale = (spark.life / spark.maxLife) ** 0.5 * 1.5;
        ctx.lineTo(spark.x - spark.xD * scale, spark.y - spark.yD * scale);

    });
    ctx.stroke();


    // Touch Strokes
    // ---------------

    ctx.strokeStyle = touchTrailColor;
    const touchPointCount = touchPoints.length;
    for (let i = 1; i < touchPointCount; i++) {
        const current = touchPoints[i];
        const prev = touchPoints[i - 1];
        if (current.touchBreak || prev.touchBreak) {
            continue;
        }
        const scale = current.life / touchPointLife;
        ctx.lineWidth = scale * touchTrailThickness;
        ctx.beginPath();
        ctx.moveTo(prev.x, prev.y);
        ctx.lineTo(current.x, current.y);
        ctx.stroke();
    }

    PERF_END('draw2D');

    PERF_END('draw');
    PERF_END('frame');

    // Display performance updates.
    PERF_UPDATE();
}





// canvas.js
// ============================================================================
// ============================================================================

function setupCanvases() {
    const ctx = canvas.getContext('2d');
    // devicePixelRatio alias
    const dpr = window.devicePixelRatio || 1;
    // View will be scaled so objects appear sized similarly on all screen sizes.
    let viewScale;
    // Dimensions (taking viewScale into account!)
    let width, height;

    function handleResize() {
        const w = window.innerWidth;
        const h = window.innerHeight;
        viewScale = h / 1000;
        width = w / viewScale;
        height = h / viewScale;
        canvas.width = w * dpr;
        canvas.height = h * dpr;
        canvas.style.width = w + 'px';
        canvas.style.height = h + 'px';
    }

    // Set initial size
    handleResize();
    // resize fullscreen canvas
    window.addEventListener('resize', handleResize);


    // Run game loop
    let lastTimestamp = 0;
    function frameHandler(timestamp) {
        let frameTime = timestamp - lastTimestamp;
        lastTimestamp = timestamp;

        // always queue another frame
        raf();

        // If game is paused, we'll still track frameTime (above) but all other
        // game logic and drawing can be avoided.
        if (isPaused()) return;

        // make sure negative time isn't reported (first frame can be whacky)
        if (frameTime < 0) {
            frameTime = 17;
        }
        // - cap minimum framerate to 15fps[~68ms] (assuming 60fps[~17ms] as 'normal')
        else if (frameTime > 68) {
            frameTime = 68;
        }

        const halfW = width / 2;
        const halfH = height / 2;

        // Convert pointer position from screen to scene coords.
        pointerScene.x = pointerScreen.x / viewScale - halfW;
        pointerScene.y = pointerScreen.y / viewScale - halfH;

        const lag = frameTime / 16.6667;
        const simTime = gameSpeed * frameTime;
        const simSpeed = gameSpeed * lag;
        tick(width, height, simTime, simSpeed, lag);

        // Auto clear canvas
        ctx.clearRect(0, 0, canvas.width, canvas.height);
        // Auto scale drawing for high res displays, and incorporate `viewScale`.
        // Also shift canvas so (0, 0) is the middle of the screen.
        // This just works with 3D perspective projection.
        const drawScale = dpr * viewScale;
        ctx.scale(drawScale, drawScale);
        ctx.translate(halfW, halfH);
        draw(ctx, width, height, viewScale);
        ctx.setTransform(1, 0, 0, 1, 0, 0);
    }
    const raf = () => requestAnimationFrame(frameHandler);
    // Start loop
    raf();
}





// interaction.js
// ============================================================================
// ============================================================================

// Interaction
// -----------------------------

function handleCanvasPointerDown(x, y) {
    if (!pointerIsDown) {
        pointerIsDown = true;
        pointerScreen.x = x;
        pointerScreen.y = y;
        // On when menus are open, point down/up toggles an interactive mode.
        // We just need to rerender the menu system for it to respond.
        if (isMenuVisible()) renderMenus();
    }
}

function handleCanvasPointerUp() {
    if (pointerIsDown) {
        pointerIsDown = false;
        touchPoints.push({
            touchBreak: true,
            life: touchPointLife
        });
        // On when menus are open, point down/up toggles an interactive mode.
        // We just need to rerender the menu system for it to respond.
        if (isMenuVisible()) renderMenus();
    }
}

function handleCanvasPointerMove(x, y) {
    if (pointerIsDown) {
        pointerScreen.x = x;
        pointerScreen.y = y;
    }
}


// Use pointer events if available, otherwise fallback to touch events (for iOS).
if ('PointerEvent' in window) {
    canvas.addEventListener('pointerdown', event => {
        event.isPrimary && handleCanvasPointerDown(event.clientX, event.clientY);
    });

    canvas.addEventListener('pointerup', event => {
        event.isPrimary && handleCanvasPointerUp();
    });

    canvas.addEventListener('pointermove', event => {
        event.isPrimary && handleCanvasPointerMove(event.clientX, event.clientY);
    });

    // We also need to know if the mouse leaves the page. For this game, it's best if that
    // cancels a swipe, so essentially acts as a "mouseup" event.
    document.body.addEventListener('mouseleave', handleCanvasPointerUp);
} else {
    let activeTouchId = null;
    canvas.addEventListener('touchstart', event => {
        if (!pointerIsDown) {
            const touch = event.changedTouches[0];
            activeTouchId = touch.identifier;
            handleCanvasPointerDown(touch.clientX, touch.clientY);
        }
    });
    canvas.addEventListener('touchend', event => {
        for (let touch of event.changedTouches) {
            if (touch.identifier === activeTouchId) {
                handleCanvasPointerUp();
                break;
            }
        }
    });
    canvas.addEventListener('touchmove', event => {
        for (let touch of event.changedTouches) {
            if (touch.identifier === activeTouchId) {
                handleCanvasPointerMove(touch.clientX, touch.clientY);
                event.preventDefault();
                break;
            }
        }
    }, { passive: false });
}





// index.js
// ============================================================================
// ============================================================================

setupCanvases();